import { useRef } from 'react'
import type { CameraOrbitType } from '@rust/kcl-lib/bindings/CameraOrbitType'
import type { CameraProjectionType } from '@rust/kcl-lib/bindings/CameraProjectionType'
import type { NamedView } from '@rust/kcl-lib/bindings/NamedView'
import type { OnboardingStatus } from '@rust/kcl-lib/bindings/OnboardingStatus'

import { NIL as uuidNIL } from 'uuid'

import { CustomIcon } from '@src/components/CustomIcon'
import Tooltip from '@src/components/Tooltip'
import type { CameraSystem } from '@src/lib/cameraControls'
import { cameraMouseDragGuards, cameraSystems } from '@src/lib/cameraControls'
import {
  DEFAULT_DEFAULT_LENGTH_UNIT,
  DEFAULT_PROJECT_NAME,
  REGEXP_UUIDV4,
} from '@src/lib/constants'
import { isDesktop } from '@src/lib/isDesktop'
import type {
  BaseUnit,
  SettingProps,
  SettingsLevel,
} from '@src/lib/settings/settingsTypes'
import { baseUnitsUnion } from '@src/lib/settings/settingsTypes'
import { Themes } from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { isEnumMember } from '@src/lib/types'
import { capitaliseFC, isArray, toSync } from '@src/lib/utils'
import { IS_STAGING_OR_DEBUG } from '@src/routes/utils'

/**
 * A setting that can be set at the user or project level
 * @constructor
 */
export class Setting<T = unknown> {
  /**
   * The current value of the setting, prioritizing project, then user, then default
   */
  public current: T
  public hideOnLevel: SettingProps<T>['hideOnLevel']
  public hideOnPlatform: SettingProps<T>['hideOnPlatform']
  public commandConfig: SettingProps<T>['commandConfig']
  public Component: SettingProps<T>['Component']
  public description?: string
  private validate: (v: T) => boolean
  public readonly isEnabled: (c: SettingsType) => boolean
  private _default: T
  private _user?: T
  private _project?: T

  constructor(props: SettingProps<T>) {
    this._default = props.defaultValue
    this.current = props.defaultValue
    this.validate = props.validate
    this.isEnabled = props.isEnabled || (() => true)
    this.description = props.description
    this.hideOnLevel = props.hideOnLevel
    this.hideOnPlatform = props.hideOnPlatform
    this.commandConfig = props.commandConfig
    this.Component = props.Component
  }
  /**
   * The default setting. Overridden by the user and project if set
   */
  get default(): T {
    return this._default
  }
  set default(v: T) {
    this._default = this.validate(v) ? v : this._default
    this.current = this.resolve()
  }
  /**
   * The user-level setting. Overrides the default, overridden by the project
   */
  get user(): T | undefined {
    return this._user
  }
  set user(v: T | undefined) {
    this._user = v !== undefined ? (this.validate(v) ? v : this._user) : v
    this.current = this.resolve()
  }
  /**
   * The project-level setting. Overrides the user and default
   */
  get project(): T | undefined {
    return this._project
  }
  set project(v: T | undefined) {
    this._project = v !== undefined ? (this.validate(v) ? v : this._project) : v
    this.current = this.resolve()
  }
  /**
   * @returns {T} - The value of the setting, prioritizing project, then user, then default
   * @todo - This may have issues if future settings can have a value that is valid but falsy
   */
  private resolve() {
    return this._project !== undefined
      ? this._project
      : this._user !== undefined
        ? this._user
        : this._default
  }
  /**
   * @param {SettingsLevel} level - The level to get the fallback for
   * @returns {T} - The value of the setting above the given level, falling back as needed
   */
  public getFallback(level: SettingsLevel | 'default'): T {
    return level === 'project'
      ? this._user !== undefined
        ? this._user
        : this._default
      : this._default
  }
  /**
   * For the purposes of showing the `current` label in the command bar,
   * is this setting at the given level the same as the given value?
   */
  public shouldShowCurrentLabel(
    level: SettingsLevel | 'default',
    valueToMatch: T
  ): boolean {
    return this[`_${level}`] === undefined
      ? this.getFallback(level) === valueToMatch
      : this[`_${level}`] === valueToMatch
  }
  public getParentLevel(level: SettingsLevel): SettingsLevel | 'default' {
    return level === 'project' ? 'user' : 'default'
  }
}

const MS_IN_MINUTE = 1000 * 60

export function createSettings() {
  return {
    // Gotcha: If you add a new setting here, you will likely need to update rust/kcl-lib/src/settings/types/mod.rs as well.
    app: {
      /**
       * The overall appearance of the app: light, dark, or system
       */
      theme: new Setting<Themes>({
        hideOnLevel: 'project',
        defaultValue: Themes.System,
        description: 'The overall appearance of the app',
        validate: (v) => isEnumMember(v, Themes),
        commandConfig: {
          inputType: 'options',
          defaultValueFromContext: (context) => context.app.theme.current,
          options: (cmdContext, settingsContext) =>
            Object.values(Themes).map((v) => ({
              name: v,
              value: v,
              isCurrent:
                v ===
                settingsContext.app.theme[
                  cmdContext.argumentsToSubmit.level as SettingsLevel
                ],
            })),
        },
      }),
      /**
       * Whether to show the debug panel, which lets you see
       * various states of the app to aid in development
       */
      showDebugPanel: new Setting<boolean>({
        defaultValue: false,
        description: 'Whether to show the debug panel, a development tool',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
      /**
       * Stream resource saving behavior toggle
       */
      streamIdleMode: new Setting<number | undefined>({
        defaultValue: 5 * MS_IN_MINUTE,
        hideOnLevel: 'project',
        hideOnPlatform: 'both',
        description: 'Save bandwidth & battery',
        validate: (v) =>
          String(v) == 'undefined' ||
          (Number(v) >= 0 && Number(v) <= 60 * MS_IN_MINUTE),
        commandConfig: {
          inputType: 'options',
          defaultValueFromContext: (context) =>
            context.app.streamIdleMode.current,
          options: (cmdContext, settingsContext) =>
            [
              undefined,
              5 * 1000,
              30 * 1000,
              1 * MS_IN_MINUTE,
              2 * MS_IN_MINUTE,
              5 * MS_IN_MINUTE,
              15 * MS_IN_MINUTE,
              30 * MS_IN_MINUTE,
              60 * MS_IN_MINUTE,
            ].map((v) => ({
              name:
                v === undefined
                  ? 'Off'
                  : v < MS_IN_MINUTE
                    ? `${Math.floor(v / 1000)} seconds`
                    : `${Math.floor(v / MS_IN_MINUTE)} minutes`,
              value: v,
              isCurrent:
                v ===
                settingsContext.app.streamIdleMode[
                  cmdContext.argumentsToSubmit.level as SettingsLevel
                ],
            })),
        },
      }),
      allowOrbitInSketchMode: new Setting<boolean>({
        /** Unhide this once we make sketch mode unbreakable */
        hideOnPlatform: 'both',
        defaultValue: false,
        description: 'Toggle free camera while in sketch mode',
        validate: (v) => typeof v === 'boolean',
      }),
      onboardingStatus: new Setting<OnboardingStatus>({
        defaultValue: '',
        // TODO: this could be better but we don't have a TS side real enum
        // for this yet
        validate: (v) => typeof v === 'string',
        hideOnPlatform: 'both',
      }),
      projectDirectory: new Setting<string>({
        defaultValue: '', // gets set async in settingsUtils.ts
        description: 'The directory to save and load projects from',
        hideOnLevel: 'project',
        hideOnPlatform: 'web',
        validate: (v) =>
          typeof v === 'string' && (v.length > 0 || !isDesktop()),
        Component: ({ value, updateValue }) => {
          const inputRef = useRef<HTMLInputElement>(null)
          return (
            <div className="flex gap-4 p-1 border rounded-sm border-chalkboard-30">
              <input
                className="flex-grow text-xs px-2 bg-transparent"
                value={value}
                disabled
                data-testid="project-directory-input"
                ref={inputRef}
              />
              <button
                onClick={toSync(async () => {
                  // In desktop end-to-end tests we can't control the file picker,
                  // so we seed the new directory value in the element's dataset
                  const inputRefVal = inputRef.current?.dataset.testValue
                  if (
                    inputRef.current &&
                    inputRefVal &&
                    !isArray(inputRefVal)
                  ) {
                    updateValue(inputRefVal)
                  } else {
                    if (!window.electron) {
                      return Promise.reject(new Error("Can't open file dialog"))
                    }
                    const newPath = await window.electron.open({
                      properties: ['openDirectory', 'createDirectory'],
                      defaultPath: value,
                      title: 'Choose a new project directory',
                    })
                    if (newPath.canceled) return
                    updateValue(newPath.filePaths[0])
                  }
                }, reportRejection)}
                className="p-0 m-0 border-none hover:bg-primary/10 focus:bg-primary/10 dark:hover:bg-primary/20 dark:focus::bg-primary/20"
                data-testid="project-directory-button"
              >
                <CustomIcon name="folder" className="w-5 h-5" />
                <Tooltip position="top-right">Choose a folder</Tooltip>
              </button>
            </div>
          )
        },
      }),
      namedViews: new Setting<{ [key in string]: NamedView }>({
        defaultValue: {},
        validate: (_v) => true,
        hideOnLevel: 'user',
      }),
    },
    /**
     * Settings that affect the behavior while modeling.
     */
    modeling: {
      /**
       * The default unit to use in modeling dimensions
       */
      defaultUnit: new Setting<BaseUnit>({
        defaultValue: DEFAULT_DEFAULT_LENGTH_UNIT,
        description:
          'Set the default length unit setting value to give any new files.',
        validate: (v) => baseUnitsUnion.includes(v),
        commandConfig: {
          inputType: 'options',
          defaultValueFromContext: (context) =>
            context.modeling.defaultUnit.current,
          options: (cmdContext, settingsContext) =>
            Object.values(baseUnitsUnion).map((v) => ({
              name: v,
              value: v,
              isCurrent:
                v ===
                settingsContext.modeling.defaultUnit[
                  cmdContext.argumentsToSubmit.level as SettingsLevel
                ],
            })),
        },
      }),
      enableSSAO: new Setting<boolean>({
        defaultValue: true,
        description:
          'Whether or not Screen Space Ambient Occlusion (SSAO) is enabled',
        validate: (v) => typeof v === 'boolean',
        hideOnPlatform: 'both', //for now
      }),
      /**
       * The controls for how to navigate the 3D view
       */
      mouseControls: new Setting<CameraSystem>({
        defaultValue: 'Zoo',
        description: 'The controls for how to navigate the 3D view',
        validate: (v) => cameraSystems.includes(v),
        hideOnLevel: 'project',
        commandConfig: {
          inputType: 'options',
          defaultValueFromContext: (context) =>
            context.modeling.mouseControls.current,
          options: (cmdContext, settingsContext) =>
            Object.values(cameraSystems).map((v) => ({
              name: v,
              value: v,
              isCurrent:
                v ===
                settingsContext.modeling.mouseControls.shouldShowCurrentLabel(
                  cmdContext.argumentsToSubmit.level as SettingsLevel
                ),
            })),
        },
        Component: ({ value, updateValue }) => (
          <>
            <select
              id="camera-controls"
              className="block w-full px-3 py-1 bg-transparent border border-chalkboard-30"
              value={value}
              onChange={(e) => updateValue(e.target.value as CameraSystem)}
            >
              {cameraSystems.map((program) => (
                <option key={program} value={program}>
                  {program}
                </option>
              ))}
            </select>
            <ul className="mx-0 my-2 flex flex-col gap-2 text-sm">
              <li className="grid grid-cols-4 gap-1">
                <strong>Pan</strong>
                <p className="col-span-3 leading-tight">
                  {cameraMouseDragGuards[value].pan.description}
                </p>
              </li>
              <li className="grid grid-cols-4 gap-1">
                <strong>Zoom</strong>
                <p className="col-span-3 leading-tight">
                  {cameraMouseDragGuards[value].zoom.description}
                </p>
              </li>
              <li className="grid grid-cols-4 gap-1">
                <strong>Rotate</strong>
                <p className="col-span-3 leading-tight">
                  {cameraMouseDragGuards[value].rotate.description}
                </p>
              </li>
            </ul>
          </>
        ),
      }),
      /** Whether to enable the touch listeners for controlling the camera, which run separate from the mouse control listeners
       *
       */
      enableTouchControls: new Setting<boolean>({
        defaultValue: true,
        hideOnLevel: 'project',
        description:
          'Enable touch events to navigate the 3D scene. When enabled, orbit with one-finger drag, pan with two-finger drag, and zoom with pinch.',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
      /**
       * Use the new sketch mode implementation - solver (Dev only)
       */
      useNewSketchMode: new Setting<boolean>({
        hideOnLevel: 'project',
        // Don't show in prod, consider switching to use AdamS's endpoint https://github.com/KittyCAD/common/pull/1704
        hideOnPlatform: IS_STAGING_OR_DEBUG ? undefined : 'both',
        defaultValue: false,
        description: 'Use the new sketch mode implementation',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
      /**
       * Projection method applied to the 3D view, perspective or orthographic
       */
      cameraProjection: new Setting<CameraProjectionType>({
        defaultValue: 'orthographic',
        hideOnLevel: 'project',
        description:
          'Projection method applied to the 3D view, perspective or orthographic',
        validate: (v) => ['perspective', 'orthographic'].includes(v),
        commandConfig: {
          inputType: 'options',
          // This is how we could have toggling behavior for a non-boolean argument:
          // Set it to "skippable", and make the default value the opposite of the current value
          // skip: true,
          defaultValueFromContext: (context) =>
            context.modeling.cameraProjection.current === 'perspective'
              ? 'orthographic'
              : 'perspective',
          options: (cmdContext, settingsContext) =>
            (['perspective', 'orthographic'] as const).map((v) => ({
              name: capitaliseFC(v),
              value: v,
              isCurrent:
                settingsContext.modeling.cameraProjection.shouldShowCurrentLabel(
                  cmdContext.argumentsToSubmit.level as SettingsLevel,
                  v
                ),
            })),
        },
      }),
      /**
       * What methodology to use for orbiting the camera
       */
      cameraOrbit: new Setting<CameraOrbitType>({
        defaultValue: 'spherical',
        hideOnLevel: 'project',
        description: 'What methodology to use for orbiting the camera',
        validate: (v) => ['spherical', 'trackball'].includes(v),
        commandConfig: {
          inputType: 'options',
          defaultValueFromContext: (context) =>
            context.modeling.cameraOrbit.current,
          options: (cmdContext, settingsContext) =>
            (['spherical', 'trackball'] as const).map((v) => ({
              name: capitaliseFC(v),
              value: v,
              isCurrent:
                settingsContext.modeling.cameraOrbit.shouldShowCurrentLabel(
                  cmdContext.argumentsToSubmit.level as SettingsLevel,
                  v
                ),
            })),
        },
      }),
      /**
       * Which type of orientation gizmo to use
       */
      gizmoType: new Setting<'cube' | 'axis'>({
        defaultValue: 'cube',
        hideOnLevel: 'project',
        description: 'Which type of orientation gizmo to use',
        validate: (v) => v === 'cube' || v === 'axis',
        commandConfig: {
          inputType: 'options',
          defaultValueFromContext: (context) =>
            context.modeling.gizmoType.current,
          options: (cmdContext, settingsContext) =>
            (['cube', 'axis'] as const).map((v) => ({
              name: capitaliseFC(v),
              value: v,
              isCurrent:
                settingsContext.modeling.gizmoType.shouldShowCurrentLabel(
                  cmdContext.argumentsToSubmit.level as SettingsLevel,
                  v
                ),
            })),
        },
      }),
      /**
       * Whether to highlight edges of 3D objects
       */
      highlightEdges: new Setting<boolean>({
        defaultValue: true,
        description: 'Whether to highlight edges of 3D objects',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
        hideOnLevel: 'project',
      }),
      /**
       * Whether to show a scale grid in the 3D modeling view
       */
      showScaleGrid: new Setting<boolean>({
        defaultValue: false,
        description: 'Whether to show a scale grid in the 3D modeling view',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
        hideOnLevel: 'project',
      }),
      fixedSizeGrid: new Setting<boolean>({
        defaultValue: true,
        description:
          'When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
      majorGridSpacing: new Setting<number>({
        defaultValue: 1,
        description:
          'The space between major grid lines, specified in the current unit',
        validate: (v) => typeof v === 'number',
        commandConfig: {
          inputType: 'number',
          min: 0,
        },
      }),
      minorGridsPerMajor: new Setting<number>({
        defaultValue: 4,
        description: 'Number of minor grid lines per major grid line',
        validate: (v) => typeof v === 'number',
        commandConfig: {
          inputType: 'number',
          min: 1,
          integer: true,
        },
      }),
      snapToGrid: new Setting<boolean>({
        defaultValue: false,
        description:
          'Snap the cursor to the unit grid when drawing lines, arcs, and other segment-based tools',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
      snapsPerMinor: new Setting<number>({
        defaultValue: 1,
        description:
          'Number of snaps between minor grid lines. 1 means snapping to every minor grid line',
        validate: (v) => typeof v === 'number',
        isEnabled: (context) => context.modeling.snapToGrid.current,
        commandConfig: {
          inputType: 'number',
          min: 0.001,
        },
      }),

      /**
       * TODO: This setting is not yet implemented.
       * Whether to turn off animations and other motion effects
       */
      // reduceMotion: new Setting<boolean>({
      //   defaultValue: false,
      //   description: 'Whether to turn off animations and other motion effects',
      //   validate: (v) => typeof v === 'boolean',
      //   commandConfig: {
      //     inputType: 'boolean',
      //   },
      //   hideOnLevel: 'project',
      // }),
      /**
       * TODO: This setting is not yet implemented.
       * Whether to move to view the sketch plane orthogonally
       * when creating entering or creating a sketch.
       */
      // moveOrthoginalToSketch: new Setting<boolean>({
      //   defaultValue: false,
      //   description: 'Whether to move to view sketch planes orthogonally',
      //   validate: (v) => typeof v === 'boolean',
      //   commandConfig: {
      //     inputType: 'boolean',
      //   },
      // }),
    },
    /**
     * Settings that affect the behavior of the KCL text editor.
     */
    textEditor: {
      /**
       * Whether to wrap text in the editor or overflow with scroll
       */
      textWrapping: new Setting<boolean>({
        defaultValue: true,
        description:
          'Whether to wrap text in the editor or overflow with scroll',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
      /**
       * Whether to make the cursor blink in the editor
       */
      blinkingCursor: new Setting<boolean>({
        defaultValue: true,
        description: 'Whether to make the cursor blink in the editor',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
    },
    /**
     * Settings that affect the behavior of project management.
     */
    projects: {
      /**
       * The default project name to use when creating a new project
       */
      defaultProjectName: new Setting<string>({
        defaultValue: DEFAULT_PROJECT_NAME,
        description:
          'The default project name to use when creating a new project',
        validate: (v) => typeof v === 'string' && v.length > 0,
        commandConfig: {
          inputType: 'string',
          defaultValueFromContext: (context) =>
            context.projects.defaultProjectName.current,
        },
        hideOnLevel: 'project',
        hideOnPlatform: 'web',
      }),
      /**
       * TODO: This setting is not yet implemented.
       * It requires more sophisticated fallback logic if the user sets this setting to a
       * non-existent file. This setting is currently hardcoded to PROJECT_ENTRYPOINT.
       * The default file to open when a project is loaded
       */
      // entryPointFileName: new Setting<string>({
      //   defaultValue: PROJECT_ENTRYPOINT,
      //   description: 'The default file to open when a project is loaded',
      //   validate: (v) => typeof v === 'string' && v.length > 0,
      //   commandConfig: {
      //     inputType: 'string',
      //     defaultValueFromContext: (context) =>
      //       context.projects.entryPointFileName.current,
      //   },
      //   hideOnLevel: 'project',
      // }),
    },
    /**
     * Settings that affect the behavior of the command bar.
     */
    commandBar: {
      /**
       * Whether to include settings in the command bar
       */
      includeSettings: new Setting<boolean>({
        defaultValue: true,
        description: 'Whether to include settings in the command bar',
        validate: (v) => typeof v === 'boolean',
        commandConfig: {
          inputType: 'boolean',
        },
      }),
    },
    /** Settings that affect the behavior of the entire app,
     *  beyond just modeling or navigating, for example
     *  NOTE: before using the project id for anything, check it isn't the
     *  NIL uuid, which is the default value.
     */
    meta: {
      id: new Setting<string>({
        hideOnLevel: 'user',
        defaultValue: uuidNIL,
        description: 'The unique project identifier',
        // Never allow the user to change the id, only view it.
        validate: (v) => REGEXP_UUIDV4.test(v),
        Component: ({ value }) => {
          return (
            <div className="flex gap-4 p-1 border rounded-sm border-chalkboard-30">
              <input
                className="flex-grow text-xs px-2 bg-chalkboard-30 dark:bg-chalkboard-80 cursor-not-allowed"
                value={value}
                disabled
                data-testid="project-id"
              />
            </div>
          )
        },
      }),
    },
  }
}

export const settings = createSettings()
export type SettingsType = ReturnType<typeof createSettings>
